Micron Document
๐ŸŽ–๏ธGitะฏั€ะฐ๐ŸŽ–๏ธ

Commit 8d1df387834cba93844fe2d0bce2f397c53735c5


Parents : 98d8828
Author : James Rich <2199651+jamesarich@users.noreply.github.com>
Signature : Signature validation error
Date : 2026-08-19T17:39:17-07:00
Committer : GitHub <noreply@github.com>
Date : 2026-08-19T19:39:17-05:00

fix(dfu): back off correctly when Android throttles BLE scan-starts (#6784)

Changes
Diff

diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/FirmwareUpdateHelpers.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/FirmwareUpdateHelpers.kt
index 4f2574a406..027f0c683b 100644
--- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/FirmwareUpdateHelpers.kt
+++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/FirmwareUpdateHelpers.kt
@@ -17,6 +17,7 @@
package org.meshtastic.feature.firmware.ota
import kotlinx.coroutines.delay
+import org.meshtastic.core.ble.BleScanStartException
import org.meshtastic.core.common.util.NumberFormatter
private const val PERCENT_MAX = 100
@@ -39,7 +40,11 @@ internal fun formatTransferProgress(progress: Float, totalBytes: Int, bytesPerSe
/**
* Runs [block] up to [attempts] times, returning the first successful [Result]. [onAttempt] fires before each attempt
- * (1-based) for progress reporting, and [retryDelayMillis] is waited between tries (never after the last). If every
+ * (1-based) for progress reporting, and [retryDelayMillis] is waited between tries (never after the last) โ€” except when
+ * a failure is (or wraps) a [BleScanStartException] carrying a `retryAfter`, in which case the wait is extended to
+ * cover it. A flat [retryDelayMillis] is nowhere near Android's BLE scan-start quota cooldown (up to its full rolling
+ * window), so retrying on schedule after a throttled scan just re-throttles every remaining attempt โ€” this is exactly
+ * the failure mode a DFU reconnect can hit after the couple of scans `detectBootloaderProtocol` already spent. If every
* attempt fails, returns the last failure so the caller can surface it however it likes (rethrow as-is vs. wrap in a
* domain exception).
*/
@@ -55,7 +60,11 @@ internal suspend fun <T> retryWithDelay(
val result = block(attempt)
if (result.isSuccess) return result
lastError = result.exceptionOrNull()
- if (attempt < attempts) delay(retryDelayMillis)
+ if (attempt < attempts) delay(maxOf(retryDelayMillis, lastError.scanStartRetryAfterMillis() ?: 0L))
}
return Result.failure(lastError ?: IllegalStateException("retryWithDelay: all $attempts attempts failed"))
}
+
+/** The scan-start cooldown a [BleScanStartException] (found directly or as this error's cause) demands, if any. */
+private fun Throwable?.scanStartRetryAfterMillis(): Long? =
+ ((this as? BleScanStartException) ?: (this?.cause as? BleScanStartException))?.retryAfter?.inWholeMilliseconds

diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuHandler.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuHandler.kt
index 9dc20b61bb..1e35ed46ea 100644
--- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuHandler.kt
+++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuHandler.kt
@@ -23,6 +23,8 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import org.koin.core.annotation.Single
import org.meshtastic.core.ble.BleConnectionFactory
+import org.meshtastic.core.ble.BleDevice
+import org.meshtastic.core.ble.BleScanStartException
import org.meshtastic.core.ble.BleScanner
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.common.util.ioDispatcher
@@ -559,13 +561,9 @@ class SecureDfuHandler(
Logger.i { "DFU: detect โ€” scanning for Legacy DFU service (${LegacyDfuUuids.SERVICE})" }
val legacyHit =
- scanForBleDevice(
- scanner = bleScanner,
+ scanForBleDeviceTolerant(
tag = "DFU detect (legacy)",
serviceUuid = LegacyDfuUuids.SERVICE,
- retryCount = 1,
- retryDelay = 0.seconds,
- scanTimeout = DETECT_SCAN_TIMEOUT,
predicate = { it.address in targetAddresses },
)
@@ -578,13 +576,9 @@ class SecureDfuHandler(
Logger.i { "DFU: detect โ€” scanning for Secure DFU service (${SecureDfuUuids.SERVICE})" }
val secureHit =
- scanForBleDevice(
- scanner = bleScanner,
+ scanForBleDeviceTolerant(
tag = "DFU detect (secure)",
serviceUuid = SecureDfuUuids.SERVICE,
- retryCount = 1,
- retryDelay = 0.seconds,
- scanTimeout = DETECT_SCAN_TIMEOUT,
predicate = { it.address in targetAddresses },
)
@@ -593,6 +587,39 @@ class SecureDfuHandler(
return detection
}
+ /**
+ * [scanForBleDevice] with `retryCount = 1`, `scanTimeout = DETECT_SCAN_TIMEOUT` โ€” detection's fixed shape โ€” but
+ * tolerant of Android's BLE scan-start quota: with no internal retry budget of its own, a throttled scan here would
+ * otherwise propagate straight out of [detectBootloaderProtocol] and abort the whole DFU attempt before a single
+ * reconnect attempt ran. Waits the reported cooldown (or the same detect timeout, if the failure carried none) and
+ * tries once more; if that also can't start a scan, returns null so detection degrades to
+ * [BootloaderDetection.Unknown] rather than crashing โ€” the fallback coordinator still tries both protocols from
+ * there, now via [retryWithDelay]'s own scan-start-aware backoff.
+ */
+ private suspend fun scanForBleDeviceTolerant(
+ tag: String,
+ serviceUuid: Uuid,
+ predicate: (BleDevice) -> Boolean,
+ ): BleDevice? {
+ repeat(2) { pass ->
+ try {
+ return scanForBleDevice(
+ scanner = bleScanner,
+ tag = tag,
+ serviceUuid = serviceUuid,
+ retryCount = 1,
+ retryDelay = 0.seconds,
+ scanTimeout = DETECT_SCAN_TIMEOUT,
+ predicate = predicate,
+ )
+ } catch (e: BleScanStartException) {
+ Logger.w(e) { "$tag: scan could not start (${e.reason}), pass ${pass + 1}/2" }
+ if (pass == 0) delay(e.retryAfter ?: DETECT_SCAN_TIMEOUT)
+ }
+ }
+ return null
+ }
+
/**
* Run the connect + init + firmware upload, retrying within the [DfuFallbackCoordinator] budget. Delegates the
* bounded retry logic to [runDfuRetryLoop] (testable without bringing up the full BLE stack); the lambdas here

Served by rngit 1.5.4 - Generated in 0.04s